Stale Values
When I first started using computers in the late 90s, I used two different media players.
For livestreaming, I had RealPlayer:
RealPlayer was OK, but it didn't really have any personality. To my teenage self, there was a much cooler option available: Winamp.
Between those early days and now, there have been countless media players, but there's something they've all had in common. Every media player I've ever used has implemented the exact same keyboard shortcut:
The spacebar key will play (or pause) the currently-selected song.
Earlier in this module, we built a simple media player, and I think we should update it to include the spacebar shortcut!
If you'd like, I'd encourage you to spend a couple minutes attempting this problem, but I'll warn you now: It's surprisingly tricky.
Code Playground
Let's talk about it.
Video Summary
To start, I've implemented the standard structure we've been seeing recently:
React.useEffect(() => { function handleKeyDown(event) { if (event.code === 'Space') { // TODO: Play or pause } }
window.addEventListener('keydown', handleKeyDown);
return () => { window.removeEventListener('keydown', handleKeyDown); };}, []);
We start our subscription, listening for keydown
events, and checking if they've pressed the spacebar key. Our cleanup function unsubscribes from this event. Because we've specified an empty dependency array, this event listener runs for the entire component lifetime.
How do we play or pause the song? Well, we have this chunk of code from earlier:
<button onClick={() => { if (isPlaying) { audioRef.current.pause(); } else { audioRef.current.play(); }
setIsPlaying(!isPlaying); }}>
The thing that makes this one a bit tricky is that we actually have two bits of state we need to manage:
- The React state, under the
isPlaying
variable. - The internal DOM audio state, which controls whether or not the song is actually playing.
One option is to create a function that manages both:
function togglePlaying() { if (isPlaying) { audioRef.current.pause(); } else { audioRef.current.play(); }
setIsPlaying(!isPlaying);}
We'd then call this function both when the user clicks the button, as well as within our handleKeydown
method.
There are two issues with this approach, however:
- We'd have to add this
togglePlaying
function to our effect's dependency array, which can introduce performance problems. We'll talk more about this when we cover useMemo and useCallback. - It's too easy to forget to use this function!
To expand on that second point, I can easily imagine another developer or myself not realizing that there are actually two relevant pieces of state.
If we're not super careful, we can wind up in scenarios where the two state variables diverge. Maybe isPlaying
is set to true, but the audio isn't playing! These issues are very tough to debug, and I'd like to make it as unlikely as possible.
So, here's my solution: We're going to add a second useEffect
hook. This hook has 1 job: to make sure that the audio DOM node's internal state is kept in sync with the isPlaying
state variable:
React.useEffect(() => { if (isPlaying) { audioRef.current.play(); } else { audioRef.current.pause(); }}, [isPlaying])
We can then update our button to only toggle the state variable:
<button onClick={() => { // This work is no longer necessary: // // if (isPlaying) { // audioRef.current.pause(); // } else { // audioRef.current.play(); // }
setIsPlaying(!isPlaying); }}>
And we can update the isPlaying
state within our keydown
callback as well:
React.useEffect(() => { function handleKeyDown(event) { if (event.code === 'Space') { setIsPlaying(!isPlaying); } }
window.addEventListener('keydown', handleKeyDown);
return () => { window.removeEventListener('keydown', handleKeyDown); };}, []);
Now, when we do this, we get a lint warning:
React Hook React.useEffect has a missing dependency: 'isPlaying'.
The problem with our code is that our effect only runs after the 1st render. It means we only have access to the very first snapshot, where isPlaying
is permanently set to false
.
Every time the user hits the spacebar key, we call setIsPlaying(!false)
. This means that the keyboard shortcut can start the song, but it can't stop it.
(In the video, there are graphs that help illustrate this; I'll place them below this summary.)
There are two potential solutions to this problem.
- Adding the dependency
- Using the callback escape hatch
The first option, adding the dependency, is something we saw in the “Effect Lint Rules” lesson. We add the isPlaying
state to the dependency array:
React.useEffect(() => { function handleKeyDown(event) { if (event.code === 'Space') { setIsPlaying(!isPlaying); } }
window.addEventListener('keydown', handleKeyDown);
return () => { window.removeEventListener('keydown', handleKeyDown); };}, [isPlaying]);
In this solution, we call our cleanup function and re-run the effect whenever isPlaying
changes. It means that we're constantly "refreshing" our keydown
callback, so that it always has access to the most recent snapshot.
In this particular scenario, this is no problem at all; it's very quick to add / remove event listeners.
But what if the subscription/unsubscription process was slow? Is there any way to access the freshest value of isPlaying
without adding it to the dependency array?
There is: Using the callback escape hatch
Here's what it looks like:
React.useEffect(() => { function handleKeyDown(event) { if (event.code === 'Space') { setIsPlaying(currentIsPlaying => { return !currentIsPlaying; }); } }
window.addEventListener('keydown', handleKeyDown);
return () => { window.removeEventListener('keydown', handleKeyDown); };}, []);
When we pass a function to our state-setter function, React will invoke this function for us, and whatever we return becomes the new state.
For example, these two statements have the exact same effect:
setIsPlaying(5);setIsPlaying(() => { return 5 });
When React invokes this function for us, it provides the current value of the state as an argument. This is the freshest value, plucked straight from the component instance.
With this workaround, we're able to keep a single event handler running for the entire component lifecycle.
Which approach is best? They both work equally well in most situations. There's a hypothetical performance benefit to this approach, but it's negligible in most cases.
It's worth becoming comfortable with both approaches. Once you understand them both, you can pick whichever you like best.
I recommend using currentX
as the name for the parameter in the callback function. So if the state variable is count
, I'd make the parameter currentCount
. I also occasionally use currentValue
. The goal is to make clear that we're not referencing the potentially-stale variable from the snapshot.
Here are the graphs from the video:
Missing dependency:
Adding isPlaying
as a dependency:
Solving using a callback function:
Here's our final implementation:
Code Playground
The state-setter callback
In the video above, we learned about a new way to set React state. It's an important concept, something we'll be using in the lessons and modules ahead, and so I wanted to cover it in more depth.
So far in this course, we've been calling state-setter functions with the new value:
const [count, setCount] = React.useState(0);
// sets `count` to `100`setCount(100);
There is an alternative syntax for state updates. If we want, we can pass a callback, and React will invoke that function for us:
// sets `count` to `100`:setCount(() => { return 100;});
Whatever we return from this function becomes the new value for the state, as if we had passed that value directly.
This is useful because React provides the current value:
setCount((currentCount) => { return currentCount + 1;});
In most cases, this isn't necessary, since we already have access to the count
variable. But, when working with effects, it becomes possible for us to lose access to the freshest version of a state variable.
In the video above, we saw an example of how this alternative syntax can be useful:
const [isPlaying, setIsPlaying] = React.useState(false);
React.useEffect(() => { function handleKeyDown(event) { if (event.code === 'Space') { setIsPlaying((currentIsPlaying) => { return !currentIsPlaying; }); } }
window.addEventListener('keydown', handleKeyDown);
return () => { window.removeEventListener('keydown', handleKeyDown); };
// No dependency on `isPlaying`!}, []);
If we try and access isPlaying
inside the handleKeyDown
callback, that value will be stale, since this effect only runs after the very first mount. By passing a callback function, we pluck the freshest value for this state variable, directly from the component instance.
When to use the callback syntax
Most of the time, we don't need to use the callback syntax. This alternative syntax is only necessary when it's possible for state variables to grow stale.
Stale state variables aren't strictly a useEffect
thing. It can also happen with other hooks that have dependency arrays, like useMemo
and useCallback
. We'll learn about them later in this module.
State variables can also grow stale when we have asynchronous code (eg. a fetch
request), since it's possible for components to re-render while that asynchronous code is running.
In practice, however, I'd say that 80-90% of the time that I use the callback syntax, it's because of the situation we've been talking about: I need to access a state variable inside an effect, but I don't want to add it to the dependency array.